/**
 * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information regarding copyright ownership. Apereo
 * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
 * this file except in compliance with the License. You may obtain a copy of the License at the
 * following location:
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apereo.portal.xml.stream;

import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLEventFactory;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.XMLEventWriter;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;
import org.apereo.portal.character.stream.CharacterEventSource;
import org.apereo.portal.character.stream.events.CharacterDataEventImpl;
import org.apereo.portal.character.stream.events.CharacterEvent;

/**
 * Used with code that serializes StAX events into a string. Watches for specific XML tags in a StAX
 * stream and chunks the string data at each XML tag occurrence.
 */
public class ChunkingEventReader extends BaseXMLEventReader {
    private static final XMLEventFactory EVENT_FACTORY = XMLEventFactory.newFactory();

    private final List<CharacterEvent> characterEvents = new ArrayList<>();

    private final HttpServletRequest request;
    private final Map<String, CharacterEventSource> chunkingElements;
    private final Map<Pattern, CharacterEventSource> chunkingPatternEventSources;
    private final Pattern[] chunkingPatterns;
    private final XMLEventWriter xmlEventWriter;
    private final StringWriter writer;

    // to handle peek() calls
    private XMLEvent peekedEvent = null;

    // Handle chunking immediately after a StartElement
    private StartElement captureEvent = null;

    public ChunkingEventReader(
            HttpServletRequest request,
            Map<String, CharacterEventSource> chunkingElements,
            Map<Pattern, CharacterEventSource> chunkingPatternEventSources,
            Pattern[] chunkingPatterns,
            XMLEventReader xmlEventReader,
            XMLEventWriter xmlEventWriter,
            StringWriter writer) {
        super(xmlEventReader);

        this.request = request;
        this.chunkingElements = chunkingElements;
        this.chunkingPatternEventSources = chunkingPatternEventSources;
        this.chunkingPatterns = chunkingPatterns;
        this.xmlEventWriter = xmlEventWriter;
        this.writer = writer;
    }

    /** @return The character events generated by the reader */
    public List<CharacterEvent> getCharacterEvents() {
        return this.characterEvents;
    }

    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }

    @Override
    public XMLEvent peek() throws XMLStreamException {
        if (this.peekedEvent == null) {
            this.peekedEvent = this.nextEvent();
        }

        return this.peekedEvent;
    }

    @Override
    protected XMLEvent internalNextEvent() throws XMLStreamException {
        XMLEvent event;

        // Read from the buffer if there was a peek
        if (this.peekedEvent != null) {
            event = this.peekedEvent;
            this.peekedEvent = null;
            return event;
        }

        final XMLEvent previousEvent = this.getPreviousEvent();
        if (previousEvent != null && previousEvent.isStartDocument()) {
            this.xmlEventWriter.flush();
            this.clearWriter();
        }

        if (this.captureEvent != null) {
            event = this.tryChunking(this.captureEvent);
            this.captureEvent = null;
        } else {
            event = this.getParent().nextEvent();
            if (event.isStartElement()) {
                final StartElement startElement = event.asStartElement();
                event = this.tryChunking(startElement);
            }
        }

        return event;
    }

    private XMLEvent tryChunking(StartElement startElement) throws XMLStreamException {
        QName elementName = startElement.getName();
        CharacterEventSource characterEventSource =
                this.chunkingElements.get(elementName.getLocalPart());
        while (characterEventSource != null) {
            final XMLEvent previousEvent = this.getPreviousEvent();
            if (previousEvent != null && previousEvent.isStartElement()) {
                this.captureEvent = startElement;

                // Write an empty Character event to force the serializer to finish writing the
                // previous StartElement
                // It is left open since ATTRIBUTE events can follow a START_ELEMENT event.
                return EVENT_FACTORY.createCharacters("");
            }

            // Capture the characters written out to this point then clear the buffer
            this.captureCharacterDataEvent();

            // Get the generated events for the element
            final XMLEventReader parent = this.getParent();
            characterEventSource.generateCharacterEvents(
                    this.request, parent, startElement, this.characterEvents);

            // Read the next event off the reader
            final XMLEvent nextEvent = parent.nextEvent();
            if (nextEvent.isStartElement()) {
                startElement = nextEvent.asStartElement();
                elementName = startElement.getName();
                characterEventSource = this.chunkingElements.get(elementName.getLocalPart());
            } else {
                return nextEvent;
            }
        }

        return startElement;
    }

    private void captureCharacterDataEvent() throws XMLStreamException {
        this.xmlEventWriter.flush();

        // Add character chunk to events
        this.chunkString(this.characterEvents, this.writer.toString(), 0);

        this.clearWriter();
    }

    /** Delete all data in the Writer */
    private void clearWriter() {
        final StringBuffer buffer = this.writer.getBuffer();
        buffer.delete(0, buffer.length());
    }

    /**
     * Breaks up the String into a List of CharacterEvents based on the configured Map of Patterns
     * to CharacterEventSources
     */
    private void chunkString(
            final List<CharacterEvent> characterEvents,
            final CharSequence buffer,
            int patternIndex) {
        // Iterate over the chunking patterns
        for (; patternIndex < this.chunkingPatterns.length; patternIndex++) {
            final Pattern pattern = this.chunkingPatterns[patternIndex];

            final Matcher matcher = pattern.matcher(buffer);
            if (matcher.find()) {
                final CharacterEventSource eventSource =
                        this.chunkingPatternEventSources.get(pattern);
                int prevMatchEnd = 0;

                do {
                    // Add all of the text up to the match as a new chunk, use subSequence to avoid
                    // extra string alloc
                    this.chunkString(
                            characterEvents,
                            buffer.subSequence(prevMatchEnd, matcher.start()),
                            patternIndex + 1);

                    // Get the generated CharacterEvents for the match
                    final MatchResult matchResult = matcher.toMatchResult();
                    eventSource.generateCharacterEvents(this.request, matchResult, characterEvents);

                    prevMatchEnd = matcher.end();
                } while (matcher.find());

                // Add any remaining text from the original CharacterDataEvent
                if (prevMatchEnd < buffer.length()) {
                    this.chunkString(
                            characterEvents,
                            buffer.subSequence(prevMatchEnd, buffer.length()),
                            patternIndex + 1);
                }

                return;
            }
        }

        // Buffer didn't match anything, just append the string data

        // de-duplication of the event string data
        final String eventString = buffer.toString();
        characterEvents.add(CharacterDataEventImpl.create(eventString));
    }

    @Override
    public boolean hasNext() {
        if (this.peekedEvent != null) {
            return true;
        }

        if (!this.getParent().hasNext()) {
            return false;
        }

        try {
            this.peekedEvent = this.nextEvent();
        } catch (NoSuchElementException e) {
            return false;
        } catch (XMLStreamException e) {
            throw new RuntimeException(e);
        }

        return true;
    }

    @Override
    public void close() throws XMLStreamException {
        captureCharacterDataEvent();
        this.getParent().close();
    }
}
